# 目录结构

服务端渲染会在用户首次访问页面的时候返回一整个页面数据(不会像 spa 一样只返回包含待执行 js 文件的 html),当浏览器接收到页面代码后,会进行客户端激活。所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。这样,就会存在两端:客户端和服务端。

项目目录结构划分如下:

└───build
| 	└───setup-dev-server.js // 开发环境的 webpack 构建逻辑
| 	└───webpack.base.config.js // 通用 webpack 配置
| 	└───webpack.client.config.js // 客户端 webpack 配置
| 	└───webpack.server.config.js // 服务端 webpack 配置
|───src
|		└───router
|					└───index.js
|					└───routes.js // 路由表
|		└───store
|					└───index.js
|					└───mutations.js
|					└───actions.js
|					└───getters.js
|		└───views // 路由组件目录
|		└───App.vue
|		└───app.js // 客户端和服务端共用逻辑
|		└───client-entry.js // 客户端入口
|		└───server-entry.js // 服务端入口
└───package.json
└───server.js // node 服务启动逻辑

# vue-server-render (opens new window)

vue-server-render 的作用是建立客户端和服务端的联系。

在客户端 webpack 打包通过使用 vue-server-render/client-plugin 插件生成客户端的资源清单 vue-ssr-client-manifest.json;在服务端 webpack 打包通过使用 vue-server-render/server-plugin 插件,将服务器的整个输出构建为单个 JSON 文件 vue-ssr-server-bundle.json;然后在 node server 通过 createBundleRenderer 方法使用客户端清单 vue-ssr-client-manifest.json 和服务端 bundle vue-ssr-server-bundle.json 构建 renderer,renderer 有了服务器和客户端的构建信息,因此它可以自动推断和注入资源预加载 / 数据预取指令(preload / prefetch directive),以及 css 链接 / script 标签到所渲染的 HTML。

# 共用逻辑

共用逻辑指的是在客户端和服务端公用的代码逻辑,有:App.vue、通用逻辑 app.js、webpack 基础配置 webpack.base.config.js、路由配置、store 配置。

# App.vue

提供根 Vue 实例挂载点及路由渲染组件。

<template>
	<div id="app">
    <router-view />
  </div>
</template>

# app.js

如果我们像写 SPA 代码那样全局共享一个根 vue 单例,由于服务端渲染是一个 node server 服务,每个请求会将对该单例进行取值存值,这样就会存在不同请求数据共享的问题。所以需要通过工厂模式为每个请求创建新的 Vue 实例,路由和 store 同理。

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router/index.js'

export function createApp() {
  const router = createRouter()
  const app = new Vue({
    router,
    render: (h) => h(App)
  })
  return { app, router }
}

# 路由

router/index.js

import Vue from 'vue'
import Router from 'vue-router'
import routes from './routes.js'

Vue.use(Router)

export function createRouter() {
  return new Router({
    mode: 'history',
    routes
  })
}

router/routes.js

export default [
  {
    path: '/',
    component: () => import(/* webpackChunkName: "Home" */ '@/views/Home.vue')
  }
]

# store

store/index.js

import Vue from 'vue'
import Vuex from 'vuex'
import actions from './actions'
import mutations from './mutations'
import getters from './getters'

Vue.use(Vuex)

export function createStore() {
  return new Vuex.Store({
    state: {},
    actions,
    mutations,
    getters
  })
}

# webpack.base.config.js

常规的 webpack 配置。

const { VueLoaderPlugin } = require('vue-loader')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const TerserPlugin = require('terser-webpack-plugin')
const path = require('path')

const isProd = process.env.NODE_ENV === 'production'

module.exports = {
  devtool: isProd ? false : '#cheap-module-source-map',
  output: {
    path: path.resolve(__dirname, '../dist'),
    publicPath: '/dist/',
    chunkFilename: 'common/[name].[chunkhash:8].js',
    filename: 'common/[name].[chunkhash].js'
  },
  resolve: {
    extensions: ['.js', '.json', '.vue', '.scss', '.css'],
    alias: {
      '@': path.resolve(__dirname, '..', 'src')
    }
  },
  optimization: {
    minimize: isProd,
    minimizer: [
      new TerserPlugin({
        parallel: true,
        terserOptions: {
          compress: {
            warnings: false,
            drop_console: isProd, // Pass true to discard calls to console.* functions
            drop_debugger: isProd // Pass true to remove debugger; statements
          },
          output: {
            comments: false
          }
        },
        extractComments: false
      })
    ]
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        loader: 'babel-loader',
        exclude: /node_modules/,
        options: {
          presets: ['@babel/preset-env'],
          plugins: [
            '@babel/plugin-transform-runtime',
            '@babel/plugin-transform-modules-commonjs'
          ]
        }
      },
      {
        test: /\.vue$/,
        loader: 'vue-loader'
      },
      {
        test: /\.(c|sa|sc)ss$/,
        use: [
          !isProd ? 'vue-style-loader' : MiniCssExtractPlugin.loader,
          {
            loader: 'css-loader',
            options: { minimize: isProd }
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {}
            }
          },
          {
            loader: 'sass-loader'
          }
        ]
      }
    ]
  },
  plugins: isProd
    ? [
        new MiniCssExtractPlugin({
          filename: 'common/[name].[contenthash:8].css',
          ignoreOrder: true
        }),
        new VueLoaderPlugin()
      ]
    : [new VueLoaderPlugin()]
}

# 数据存储

由于存在客户端和服务端,需要保持两端数据同步,不然在激活客户端的时候,会导致混合失败。数据存储使用的是 vuex store,使用方式是通过在 vue 中注入 asyncData 方法。

在服务端, asyncData 方法会在路由器完成初始化导航后执行,通过 asyncData 获取数据后会获取存储到 store 中,然后将数据注入到上下文 context.state 中,context.state 会在客户端自动序列化为window.__INITIAL_STATE__;在客户端,会获取window.__INITIAL_STATE__并同步到客户端的 store 中。这样就实现了两端数据同步。

# 服务端入口 server-entry.js

由于是服务端,没有像客户端一样有浏览器的 url,所以需要手动触发当前路由的渲染。

当路由器完成初始化导航后,获取对应路由组件的 asyncData 方法并执行 asyncData 方法,同时将数据注入到上下文 context.state 中,context.state 会在客户端自动序列化为window.__INITIAL_STATE__,执行结束才渲染。

import { createApp } from './app'

const isProd = process.env.NODE_EVN === 'production'

export default (context) => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()
    router.push(context.url) // 手动添加当前的访问 url,该 context 为 vue-server-render 注入的 context 
    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents() // 匹配对应的路由组件
      if (!matchedComponents.length) {
        return reject({ code: 404 })
      }
      Promise.all(
        // 执行 asyncData 逻辑
        matchedComponents.map(
          ({ asyncData }) =>
            asyncData &&
            asyncData({
              store,
              route: router.currentRoute
            })
        )
      )
        .then(() => {
          context.state = store.state // context.state 在 client 自动序列化为 window.__INITIAL_STATE__
          resolve(app)
        })
        .catch(reject)
    }, reject)
  })
}

# 客户端入口 client-entry.js

在客户端,获取window.__INITIAL_STATE__并同步到客户端的 store 中,实现两端数据的同步。

当路由跳转时,需要执行对应的 asyncData 方法,所以需要在全局路由守卫 beforeResolve(导航即将解析之前执行)获取对应路由组件的 asyncData 方法并执行。

当路由更新时,需要对当前路由组件的数据进行更新,所以需要通过 mixin 方法注入到每个路由组件中,通过对应的 beforeRouteUpdate 路由守卫执行对应的 asyncData 方法。

import Vue from 'vue'
import { createApp } from './app'

Vue.mixin({
  beforeRouteUpdate(to, from, next) {
    const { asyncData } = this.$options
    if (asyncData) {
      asyncData({
        store: this.$store,
        route: to
      })
        .then(next)
        .catch(next)
    } else {
      next()
    }
  }
})

const { app, router, store } = createApp()

if (window.__INITIAL_STATE__) {
  // 同步服务端数据到客户端的 store 中
  store.replaceState(window.__INITIAL_STATE__)
}

router.onReady(() => {
  router.beforeResolve((to, from, next) => {
    const matched = router.getMatchedComponents(to)
    const prevMatched = router.getMatchedComponents(from)
    let diffed = false
    const activated = matched.filter((c, i) => {
      return diffed || (diffed = prevMatched[i] !== c)
    })
    const asyncDataHooks = activated.map((c) => c.asyncData).filter((_) => _)
    if (!asyncDataHooks.length) {
      return next()
    }

    Promise.all(
      asyncDataHooks.map((hook) =>
      	hook({
        	store,
        	route: to
      	})
			)
    )
    .then(() => {
      next()
    })
      .catch(next)
  })

  app.$mount('#app')
})

# 客户端 webpack 配置

webpack.client.config.js 主要配置客户端的 entry,和使用 vue-server-render/client-plugin 插件生成客户端的资源清单。

const webpack = require('webpack')
const { merge } = require('webpack-merge')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const baseConfig = require('./webpack.base.config.js')

module.exports = merge(baseConfig, {
  mode: 'production',
  entry: {
    app: require('path').resolve(__dirname, '../src/client-entry.js')
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(
        process.env.NODE_ENV || 'development'
      ),
      'process.env.VUE_ENV': '"client"'
    }),
    // 生成客户端资源清单 `vue-ssr-client-manifest.json`。
    new VueSSRClientPlugin()
  ]
})

# 服务端 webpack 配置

webpack.server.config.js 主要配置服务端入口,还有使用 vue-server-render/server-plugin 生成服务端输出 bundle JSON 文件。

const webpack = require('webpack')
const { merge } = require('webpack-merge')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
const baseConfig = require('./webpack.base.config.js')

module.exports = merge(baseConfig, {
  mode: 'production',
  entry: {
    app: require('path').resolve(__dirname, '../src/client-entry.js')
  },
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify(
        process.env.NODE_ENV || 'development'
      ),
      'process.env.VUE_ENV': '"client"'
    }),
    // 服务端输出 bundle JSON 文件 `vue-ssr-client-manifest.json`。
    new VueSSRServerPlugin()
  ]
})

# 生产环境

在 package.json 配置 script 命令。

{
    "clean": "rimraf ./dist",
    "prebuild": "npm run clean",
    "build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
    "build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
    "build": "npm run build:client & npm run build:server",
}

执行 npm run build,生成打包目录:

image-20220520114822880

在 server.js 中,添加逻辑:

  • 获取客户端 webpack 资源清单和服务端打包输出的 bundle JSON 文件,通过 vue-server-render 输出 html。
  • 启动静态资源服务器。
  • 启动 node server 服务器。
const fs = require('fs')
const { resolve } = require('path')
const express = require('express')
const { createBundleRenderer } = require('vue-server-renderer')
const app = express()

const serve = (path, cache) =>
  express.static(resolve(path), {
    maxAge: 0
  })

const isProd = process.env.NODE_ENV === 'production'
const resolvePath = (str) => resolve(__dirname, str)
const templatePath = resolvePath('./src/template.index.html')

const createHtml = (renderer, context) =>
  new Promise((resolve, reject) => {
    renderer.renderToString(context, (err, html) => {
      if (err) {
        return reject(err)
      }
      resolve(html)
    })
  })

let renderer = null
const serverBundle = require('./dist/vue-ssr-server-bundle.json') // 获取服务端打包输出 bundle JSON 文件
const clientManifest = require('./dist/vue-ssr-client-manifest.json') // 获取客户端打包静态资源清单
const template = fs.readFileSync(templatePath, {
  encoding: 'utf-8'
})
renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  clientManifest,
  template
})

app
  .use('/dist', serve('./dist', true)) // 静态资源服务器
  .get('*', async (req, res) => {
    try {
      if (req.path === '/favicon.ico') {
        return
      }
      // context 全局上下文数据,也可用于替换 template html 模版中的 {{xxx}}
      const context = {
        title: 'My tiny vue ssr project.',
        url: req.url
      }
      const html = await createHtml(renderer, context)
      res.send(html)
    } catch (error) {
      console.error('>> res :', error)
    }
  })
  .listen(8080, () => {
    console.log(`服务器地址: localhost:8080`)
  })

# 开发环境

在 package.json 配置 script 命令。

{
    // ...
    "dev": "cross-env NODE_ENV=dev node server.js"
}

由于是 node server 服务,所以需要调用 webpack 的 node API 对两端进行打包。

开发环境需要实现热更新,使用 webpack-dev-middleware、webpack-hot-middleware、webpack 内置插件 HotModuleReplacementPlugin 实现。

# webpack 配置

const webpack = require('webpack')
const MFS = require('memory-fs')
const fs = require('fs')
const chokidar = require('chokidar')

const readFile = (fs, file, outputPath) => {
  try {
    return fs.readFileSync(require('path').join(outputPath, file), 'utf-8')
  } catch (err) {
    console.error(err)
  }
}

function createClientWebpackConfig() {
  const clientWebpackConfig = require('./webpack.client.config.js')
  clientWebpackConfig.mode = 'development' // 修改 mode 为 development
  clientWebpackConfig.output.filename = '[name].js'
  // 设置 webpack-hot-middleware
  clientWebpackConfig.entry.app = [
    'webpack-hot-middleware/client', // webpack-hot-middleware/client 配置
    clientWebpackConfig.entry.app
  ]
  // 使用 webpack 内置插件 HotModuleReplacementPlugin
  clientWebpackConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
  if (!('optimization' in clientWebpackConfig)) {
    clientWebpackConfig.optimization = {}
  }
  clientWebpackConfig.optimization.noEmitOnErrors = true

  return clientWebpackConfig
}

function createServerWebpackConfig() {
  const serverWebpackConfig = require('./webpack.server.config.js')
  serverWebpackConfig.mode = 'development' // 修改 mode 为 development
  return serverWebpackConfig
}

module.exports = async function setupDevServer(app, templatePath, cb) {
  try {
    let bundle
    let template
    let clientManifest
    let ready
    const readyPromise = new Promise((r) => {
      ready = r
    })
    const update = () => {
      if (bundle && clientManifest) {
        ready()
        // 执行外部回调,这里执行逻辑是通过 vue-server-render 生成 renderer
        cb(bundle, {
          template,
          clientManifest
        })
      }
    }

    // 监听 html template 模版文件的变更
    template = fs.readFileSync(templatePath, 'utf-8')
    chokidar.watch(templatePath).on('change', () => {
      template = fs.readFileSync(templatePath, 'utf-8')
      console.log('index.html template updated.')
      update()
    })

    // 初始化 client webpack
    const clientWebpackConfig = createClientWebpackConfig()
    const outputPath = clientWebpackConfig.output.path
    const clientCompiler = webpack(clientWebpackConfig)
    const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
      publicPath: clientWebpackConfig.output.publicPath
    })
    app
      .use(devMiddleware) // 使用 webpack-dev-middleware 中间件
      .use(
        // 使用 webpack-hot-middleware 中间件
        require('webpack-hot-middleware')(clientCompiler, {
          heartbeat: 5000
        })
      )
    clientCompiler.hooks.done.tap('done', (stats) => {
      stats = stats.toJson()
      stats.errors.forEach((err) => console.error(err))
      stats.warnings.forEach((err) => console.warn(err))
      if (stats.errors.length) return
      clientManifest = JSON.parse(
        readFile(
          devMiddleware.context.outputFileSystem,
          'vue-ssr-client-manifest.json',
          outputPath
        )
      )
      update()
    })

    // 初始化 server webpack
    const serverWebpackConfig = createServerWebpackConfig()
    const serverCompiler = webpack(serverWebpackConfig)
    const mfs = new MFS() // 这里 server 端的文件读写方式修改为内存,提高读写效率
    serverCompiler.outputFileSystem = mfs
    serverCompiler.watch({}, (err, stats) => {
      if (err) throw err
      stats = stats.toJson()
      if (stats.errors.length) return
      bundle = JSON.parse(
        readFile(mfs, 'vue-ssr-server-bundle.json', outputPath)
      )
      update()
    })

    return readyPromise
  } catch (error) {
    console.error('>> setupDevServer error :', error)
  }
}

# 修改 server.js

const fs = require('fs')
const { resolve } = require('path')
const express = require('express')
const { createBundleRenderer } = require('vue-server-renderer')
const app = express()

const serve = (path, cache) =>
  express.static(resolve(path), {
    maxAge: 0
  })

const isProd = process.env.NODE_ENV === 'production'
const resolvePath = (str) => resolve(__dirname, str)
const templatePath = resolvePath('./src/template.index.html')
const createHtml = (renderer, context) =>
  new Promise((resolve, reject) => {
    renderer.renderToString(context, (err, html) => {
      if (err) {
        return reject(err)
      }
      resolve(html)
    })
  })

let renderer = null
if (isProd) {
  const serverBundle = require('./dist/vue-ssr-server-bundle.json')
  const clientManifest = require('./dist/vue-ssr-client-manifest.json')
  const template = fs.readFileSync(templatePath, {
    encoding: 'utf-8'
  })

  renderer = createBundleRenderer(serverBundle, {
    runInNewContext: false,
    clientManifest,
    template
  })
} else {
  require('./build/setup-dev-server.js')(
    app,
    templatePath,
    (bundle, options) => {
      renderer = createBundleRenderer(bundle, options)
    }
  )
}

app
  .use('/dist', serve('./dist', true))
  .get('*', async (req, res) => {
    try {
      if (req.path === '/favicon.ico') {
        return
      }
      const context = {
        title: 'My mini vue ssr project.',
        url: req.url
      }
      const html = await createHtml(renderer, context)
      res.send(html)
    } catch (error) {
      console.error('>> res :', error)
    }
  })
  .listen(8080, () => {
    console.log(`服务器地址: localhost:8080`)
  })

参考内容:

  • https://ssr.vuejs.org/zh/
  • https://github.com/vuejs/vue-hackernews-2.0/